Screen Structure and Wiring
This document explains how a feature screen is structured in Baselines and how its pieces are wired together. Each component has a single responsibility and a clear place in the flow from user interaction to UI rendering.
The goal is:
- Predictable screen structure
- Explicit data flow
- Minimal recompositions
- Easy testability and maintenance
Overview: How a Screen Is Composed
A feature screen is built from the following building blocks:
UiEvent— user intentUiState— single source of truth for renderingViewModel— UI logic and state producerScreen— pure UIRoute— wiring layerUiModule— dependency injection and navigation registration
Each layer exists to separate concerns and keep the UI stable as the feature grows.
1. UiEvent — Capturing User Actions
Responsibility: Represent everything the user can do on the screen.
UiEvent is a sealed type that enumerates all user interactions such as clicks, gestures, or selections.
kotlin1sealed interface ProfileUiEvent : UiEvent {2 data object PerformLogout : ProfileUiEvent {3 override val dispatchPolicy = UiEventDispatchPolicy.ThrottleFirst()4 }5}
Guidelines:
- Model intent, not UI mechanics.
- Start minimal and grow as the feature evolves.
- Override
dispatchPolicyonly when the event needs non-default dispatch behavior.- Use
ThrottleFirstfor one-shot tap actions andDebounceLatestfor latest-value effects like search.- For controlled inputs such as text fields, keep the visible input state immediate and debounce only the delayed side effect.
2. UiState — Single Source of Truth
Responsibility: Hold all data required to render the screen.
The UI reads from UiState only. There is no implicit state hidden in the composables.
kotlin1@Immutable2data class ProfileUiState(3 val sections: ImmutableList<Section>,4 override val eventSink: (ProfileUiEvent) -> Unit,5) : UiState<ProfileUiEvent>
Guidelines:
- Annotate with
@Immutableto reduce recompositions.- Keep state explicit.
- If the state grows, split it into smaller nested data classes.
- Pass the stable
eventSinkthroughUiStateinstead of recreating event lambdas instate().
3. ViewModel — UI Logic and State Producer
Responsibility: Own UI logic and produce UiState.
The ViewModel:
- Coordinates domain logic.
- Transforms data into UI-ready state.
- Exposes a single
state()entry point.
kotlin1@Inject2@ContributesIntoMap(AppScope::class, binding<ViewModel>())3@ViewModelKey(ProfileViewModel::class)4class ProfileViewModel : ViewModel(), Mvvm<ProfileUiEvent, ProfileUiState> {56 private val eventSink = createEventSink(::handleEvent)7 private val sectionsFlow = mutableState(persistentListOf<Section>()) { createSections() }89 @Composable10 override fun state(): ProfileUiState {11 val currentSections by sectionsFlow.collectAsStateWithLifecycle()12 return ProfileUiState(13 sections = currentSections,14 eventSink = eventSink,15 )16 }1718 private fun handleEvent(event: ProfileUiEvent) {19 when (event) {20 ProfileUiEvent.PerformLogout -> handleLogout()21 }22 }2324 private fun handleLogout() {25 /* domain coordination */26 }2728 private suspend fun createSections(): ImmutableList<Section> {29 /* data preparation */30 }31}
Why state() is composable
state() is marked @Composable so it can:
- Participate in Compose snapshots.
- Automatically recompose when state changes.
- Expose stable references to the UI.
When to use assisted injection
The default path is a plain @Inject ViewModel created with metroViewModel(). Switch to @AssistedInject only when the ViewModel needs runtime-provided arguments that the graph cannot supply on its own, such as route parameters, IDs, or navigation payloads. In that case, use assistedMetroViewModel() at the call site.
4. Screen — Pure UI Layer
Responsibility: Render UI only.
The Screen:
- Contains no logic.
- Holds no state.
- Forwards user interactions via callbacks.
kotlin1@Composable2fun ProfileScreen(3 sections: ImmutableList<Section>,4 onLogoutClicked: () -> Unit,5) {6 /* UI layout */7}
Guidelines:
- Keep screens stateless.
- Never call ViewModel directly.
- Treat callbacks as event emitters only.
5. Route — Wiring State to UI
Responsibility: Bind ViewModel state to the Screen.
The Route:
- Pulls state from the ViewModel.
- Extracts stable references.
- Connects UI callbacks to
UiEvents.
kotlin1@Composable2fun ProfileRoute(viewModel: ProfileViewModel) {3 val state = viewModel.state()4 val eventSink = state.eventSink5 ProfileScreen(6 sections = state.sections,7 onLogoutClicked = {8 eventSink(ProfileUiEvent.PerformLogout)9 },10 )11}
💡 State provided by the ViewModel may change frequently. By extracting
eventSinkoutside callbacks you keep the wiring stable and avoid redundant recompositions.
Why this layer exists
Separating the Route:
- Keeps screens pure.
- Prevents accidental recompositions.
- Centralizes wiring logic.
6. UiModule — Dependency Injection and Navigation
Responsibility: Register the screen in the navigation graph.
The UiModule:
- Contributes navigation entries.
- Wires ViewModel factories.
- Keeps navigation setup out of UI code.
kotlin1@ContributesTo(UiScope::class)2interface ProfileUiModule {34 @Provides5 @IntoSet6 fun provideProfileNavGraphEntry(): NavGraphEntry = NavGraphEntry {7 composable<AppNavRoutes.Profile> {8 ProfileRoute(metroViewModel())9 }10 composable<AppNavRoutes.EditProfile> {11 EditProfileRoute(metroViewModel())12 }13 }14}
Guidelines:
- Use unique
provide...function names.- Use
metroViewModel()for the normal case where the ViewModel is created entirely from graph-provided dependencies.- Use
assistedMetroViewModel()only when the ViewModel needs runtime args from navigation or route parameters.
Mental Model
Think of a screen as a pipeline:
User Action → UiEvent → ViewModel → UiState → Screen
Each layer has:
- One responsibility
- One direction of data flow
- No hidden coupling
Further Reference
For a more advanced example that:
- Combines multiple flows
- Reflects loading and error states
- Demonstrates complex state coordination
See PlaygroundViewModel.